Skip to content

v1.24.0: step 20's verify used a two-dot diff and reported main's newer files as your deletions - #21

Merged
wan-huiyan merged 1 commit into
mainfrom
fix/step-20-three-dot-diff
Aug 10, 2026
Merged

v1.24.0: step 20's verify used a two-dot diff and reported main's newer files as your deletions#21
wan-huiyan merged 1 commit into
mainfrom
fix/step-20-three-dot-diff

Conversation

@wan-huiyan

Copy link
Copy Markdown
Owner

The wrong line

plugins/session-handoff/SKILL.md, step 20, the rebuild path for a branch that was already squash-merged (git reset --hard origin/main + cherry-pick the docs commits). It ended with:

git diff --stat origin/main..HEAD

Two dots compare tip to tip. Everything main gained after you branched therefore renders as a deletion you appear to be making. Two dots are correct only in the instant of the hard reset, and wrong the moment any parallel session merges before you push — which is exactly the situation the step exists for. In the repo where this was found, main moved four times in two days and this line fired spuriously twice, each time starting an investigation into a data loss that had not happened; one of them got a whole exploration prompt written for it.

Verified against GitHub: for a real PR, GitHub reported 5 files / 219 insertions / 16 deletions, and git diff --stat <base>...<head> reproduced that exactly, while the two-dot form returned a different file set and invented deletions.

The fix

Three dots, plus a deletion gate the step never had:

git diff --stat origin/main...HEAD                       # only the docs files
git diff --diff-filter=D --name-only origin/main...HEAD  # must be empty

Three dots tell you what the branch proposes; they do not by themselves make the check loud about the thing that matters. The deletion line does. The hazard is real and was reproduced in a fixture repo for this PR: a branch whose tree is stale — from git reset --soft origin/main, or an old worktree committed with git add -A — passes git merge-base --is-ancestor, reports 0 commits behind, and a fast-forward push then replaces main's tree wholesale. Fixture output:

-- merge-base --is-ancestor: YES — fast-forwardable, reports 0 commits behind
-- git diff --stat main...fix/copy-tweak:
 client-facing.md | 322 -----------------------------------------
 copy.txt         |   2 +-
-- deletion gate: client-facing.md

In the source repo, a PR of exactly that shape deleted a client-facing 322-line file from main while describing itself as a copy fix; a follow-up PR restored it 17 minutes later. The deletion gate is the one command that sees it.

Deliberate hardening: the two $BASE..HEAD sites

I checked the BASE-resolution logic rather than guessing, and changed both. scripts/reverse_lint_step.sh hard-SKIPs unless BASE resolves as a revision, and step 24b's fence does the same — but resolving is not the same as being on this branch's history, nothing enforces the ancestor property, and step 20's own rebuild destroys it outright.

  • Where BASE is an ancestor (the contracted case), A..B and A...B are byte-identical. No behaviour change.
  • Where it is not, two dots report main's own newer files as though this session had deleted them.

So both now use three dots. Three dots need a fork point to exist at all, so both also gain a git merge-base guard — a BASE from an unrelated history now reports SKIPPED with its reason rather than letting an erroring diff read as "no lessons file changed" / "no SKILL.md was touched". That is this skill's own rule that clean and never ran must not look alike, applied to the one case where three dots could have introduced a silent skip.

Left alone on purpose

git log --oneline origin/BRANCH..HEAD in step 19. In git log, two dots mean "commits reachable from B but not A" — exactly what "my unpushed commits" wants. Three dots there would be the symmetric difference and wrong.

Verification

  • npm test green: 79 tests, 0 fail, 2 pre-existing skips (eval-suite.json not present). The suite executes reverse_lint_step.sh end to end against fixture HOMEs, so the script change is covered rather than only grepped.
  • Hand-checked the edited script in a throwaway repo: ancestor BASE scans as before (clean (1 file(s) scanned)), orphan BASE hits the new guard, literal HEAD~N still hits the old one, sh -n clean.
  • Ran this PR's own check before pushing: git diff --diff-filter=D --name-only origin/main...HEAD empty, and the three-dot stat shows only the six intended files.
  • Version bumped 1.23.0 → 1.24.0 in all five places the repo records it (package.json, .claude-plugin/marketplace.json, plugins/session-handoff/.claude-plugin/plugin.json, SKILL.md frontmatter, README Version History), which the manifest-consistency tests cross-check.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JfM4bKm5Y8F8KqFB6xjKFP

…n one afternoon

Step 20's rebuild path — `git reset --hard origin/main` + cherry-pick, for when the
feature branch was already squash-merged — ended with

    git diff --stat origin/main..HEAD

to confirm only the docs files remained before pushing.

Two dots compare tip to tip. Every file `main` gained after you branched therefore
renders as a deletion you appear to be making. That is right only in the instant of
the hard reset, and wrong the moment any parallel session merges before you push —
which is the exact situation the step exists for. In the repo where this was found
`main` moved four times in two days and the line fired spuriously twice, each time
starting an investigation into a data loss that had not happened.

Verified against GitHub this session: for a real PR, GitHub reported 5 files / 219
insertions / 16 deletions and `git diff --stat <base>...<head>` reproduced it exactly,
while the two-dot form returned a different file set and invented deletions.

Now three dots, plus a second line the step never had:

    git diff --diff-filter=D --name-only origin/main...HEAD   # must be empty

Three dots tell you what the branch proposes; they do not by themselves make the check
loud about the thing that matters. A branch whose TREE is stale — from `git reset --soft
origin/main`, or an old worktree committed with `git add -A` — passes `git merge-base
--is-ancestor`, reports 0 commits behind, and a fast-forward push then replaces main's
tree wholesale. Reproduced in a fixture repo here: fast-forwardable, 0 behind, and the
deletion gate is the only one of the three checks that sees the 322-line file going
away. In the source repo a PR of exactly that shape deleted a client-facing 322-line
file while describing itself as a copy fix; a follow-up restored it 17 minutes later.

Deliberate hardening, same idea, two more sites: `"$BASE"..HEAD` in step 24b and in
reverse_lint_step.sh. BASE is contractually a SHA on the branch's own history, and where
that holds the two forms are byte-identical — but nothing enforces it, and step 20's own
rebuild destroys the ancestor property outright. Both now use three dots. Three dots need
a fork point to exist, so both also gain a `git merge-base` guard: a BASE from an
unrelated history now reports SKIPPED with its reason rather than letting an erroring
diff read as "nothing changed", which is this skill's own rule about clean and never-ran
not looking alike.

Left alone on purpose: `git log --oneline origin/BRANCH..HEAD` in step 19. In `git log`
two dots mean "commits reachable from B but not A" — exactly what "my unpushed commits"
wants. Three dots there would be the symmetric difference and wrong.

Verification: `npm test` green — 79 tests, 0 fail, 2 pre-existing skips (no
eval-suite.json). The suite executes reverse_lint_step.sh end to end against fixture
HOMEs. Also checked by hand: ancestor BASE scans as before, orphan BASE hits the new
guard, literal HEAD~N still hits the old one, `sh -n` clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JfM4bKm5Y8F8KqFB6xjKFP
@wan-huiyan
wan-huiyan merged commit 875e7b9 into main Aug 10, 2026
3 checks passed
@wan-huiyan
wan-huiyan deleted the fix/step-20-three-dot-diff branch August 10, 2026 19:02
@wan-huiyan
wan-huiyan restored the fix/step-20-three-dot-diff branch August 11, 2026 22:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant